-
Notifications
You must be signed in to change notification settings - Fork 321
Support Share to Zulip on Android #1774
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
Great, glad to see this! I've watched the three videos. The UX generally looks good. Comments there:
/cc @alya also for her feedback on the UX. I haven't yet looked much at the code (since this is a draft), just briefly skimmed. It looks like one key thing still for you to do is to split the changes out into distinct commits, which will help with reading them. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, and here's comments from a high-level scan of the code. Excited to see this feature!
|
||
class MainActivity: FlutterActivity() { | ||
class MainActivity : FlutterActivity() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There's a fair amount of code added here. Was there a particular place (or handful of places) you looked for working out what this code should look like? That'll be helpful to point to in commit messages.
For example, specific files in the legacy app; or places in the Flutter tree; or Android documentation; or other sources.
bytes = bytes) | ||
} | ||
|
||
private fun handleSend(intent: Intent) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can this method (and its helper getIntentSharedFileInfo) move onto a class other than MainActivity? Perhaps on AndroidIntentEventListener, since that seems closely related.
That would help for making it explicit what information or methods they're using, if any, from FlutterActivity or its base classes.
Even if the methods have to be passed this
as a FlutterActivity (or some supertype of that), I think the explicitness of accessing FlutterActivity members on that argument would be helpful.
lib/widgets/app.dart
Outdated
@@ -161,6 +165,40 @@ class _ZulipAppState extends State<ZulipApp> with WidgetsBindingObserver { | |||
super.initState(); | |||
WidgetsBinding.instance.addObserver(this); | |||
UpgradeWelcomeDialog.maybeShow(); | |||
|
|||
// Move to a AndroidShareService or similar. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed, this would be a helpful bit of code organization.
I think a good home for that would be in lib/widgets/share.dart . It's fundamentally closely tied to the SharePage widget.
lib/widgets/app.dart
Outdated
ZulipBinding.instance.androidIntentEvents.forEach((androidIntentEvent) async { | ||
assert(debugLog('androidIntentEvent.action: ${androidIntentEvent.action}')); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's also make this callback a named function. Can be a private static method on the relevant class.
lib/widgets/compose_box.dart
Outdated
@override | ||
void insertText(String newText) { | ||
final contentController = controller.content; | ||
contentController.value = contentController.value.replaced(contentController.insertionIndex(), '$newText\n'); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about inlining this at the call site? The caller can use the controller
getter, which is already exposed in the ComposeBoxState interface.
(That's effectively what we do for things like quote-and-reply.)
lib/widgets/new_dm_sheet.dart
Outdated
Navigator.pop(context); // TODO why doesn't this work? | ||
onSelect!(narrow); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure if this TODO is just a note to yourself or if you'd like input on it — if the latter, I'd be curious to hear what behavior you're seeing, and what behavior you expected 🙂
Navigator.push(context, | ||
MessageListPage.buildRoute(context: context, narrow: narrow)); | ||
if (onSelect != null) { | ||
onSelect!(narrow); | ||
} else { | ||
Navigator.push(context, | ||
MessageListPage.buildRoute(context: context, | ||
narrow: narrow)); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar comment as for the onSelect on _NewDmHeader above.
lib/widgets/subscription_list.dart
Outdated
onLongPress: () => showChannelActionSheet(context, channelId: subscription.streamId), | ||
onLongPress: !disableChannelActionSheet | ||
? () => showChannelActionSheet(context, channelId: subscription.streamId) | ||
: null, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting. What goes wrong without this change, if the user does try to open a channel action sheet?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The issue was that user could open the Topic List page from that action sheet, and when selected a topic from that topic list, the compose box of the topic message list wouldn't be populated with the content.
See discussion: #mobile-design > share to Zulip @ 💬
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The latest revision now replaces this flag with one to disable just the topic list button from the channel action sheet, rather than disabling whole action sheet.
// TODO separate out API calls for resolving file name, getting mimetype, getting bytes? | ||
class IntentSharedFile { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That sounds potentially useful but definitely a follow-up task, if this version already seems to work correctly 🙂
lib/widgets/share.dart
Outdated
if (filesToUpload != null) { | ||
await composeBoxState.uploadFiles(filesToUpload!); | ||
} else if (sharedText != null) { | ||
composeBoxState.insertText(sharedText!); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this need the else
? It seems like this could work just fine with both if
blocks acting independently.
Probably then put the text before the uploads.
I'm imagining that there might be sharing sources that include both some text and some images or other files. I'm not sure if the platform APIs support that; but it's definitely a natural thing for people to want to do, so perhaps they might. (After all, even when the thing shared from the other app is purely an image, people often/usually want to add to add some text before sending it, in Zulip as well as in many other apps.)
Yeah, I also wanted to note that it would be more clear to label the tabs as "Channels" and "Direct messages" (or "DMs" if that fits better). Looks promising! |
78f40a3
to
0c75b06
Compare
Thanks for the review @gnprice! Pushed an update and also updated the screenshots and recording, PTAL. |
0c75b06
to
8f0e582
Compare
Current revision tries to handle this.
Filed #1779.
Fixed.
Added the video in the description, specifically it's the one labelled as "Text (New DM)". |
In your screenshots, why does the list of channels start with "Unpinned"? I would expect that to appear only if there are pinned channels above. |
Looks like we show the "Unpinned" header unconditionally on Subscription list page: zulip-flutter/lib/widgets/subscription_list.dart Lines 111 to 114 in d5dddb3
|
Thanks for the revision! From the updated videos, the one UX comment I have is: after choosing a stream, it looks like focus starts out in the content input, and gets moved to the topic input only after the upload completes. Can we instead move it to the topic input immediately? That way the user can be choosing the topic while the upload is happening. |
lib/widgets/share.dart
Outdated
// Then upload the files and populate the compose box with their links. | ||
if (sharedFiles != null) { | ||
await composeBoxState.uploadFiles(sharedFiles!); | ||
} | ||
|
||
// Try to focus on the topic compose box if there is one, | ||
// else focus on content compose box, if not already focused. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think specifically that means this latter step should happen before doing an await
on the former step.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Fixed in current revision, and also updated videos, PTAL.
8f0e582
to
93f4417
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, this is exciting! I didn't use the legacy app's implementation at all in my regular Zulip use, but from what I remember, this feels much smoother. Comments below.
lib/widgets/compose_box.dart
Outdated
@@ -928,7 +928,7 @@ class FileToUpload { | |||
Future<void> _uploadFiles({ | |||
required BuildContext context, | |||
required ComposeContentController contentController, | |||
required FocusNode contentFocusNode, | |||
required FocusNode? contentFocusNode, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
compose [nfc]: Expose uploadFiles on ComposeBoxState
This will be used soon to allow uploading files directly
via using `MessageListPageState`.
I don't love this change to _uploadFiles
's interface: on the surface, it looks like contentController
and contentFocusNode
disagree about whether the content input always or only sometimes exists.
From the later commit, I can infer that this change isn't actually about the presence/absence of the content input; it's about a detail of how the function interacts with the input. Instead of doing this, how about making a flag that's specific to that behavior detail, like bool shouldRequestFocus = true
?
Relatedly (but I wouldn't block on it), _uploadFiles
could get its contentController
and contentFocusNode
from the ComposeBoxController
, couldn't it? That could be a single param to replace those two params. Or it could even come from the context
param, via ComposeBoxInheritedWidget.of
, with some prep work to add a field for it to ComposeBoxInheritedWidget
.
lib/widgets/compose_box.dart
Outdated
@@ -1869,6 +1869,24 @@ abstract class ComposeBoxState extends State<ComposeBox> { | |||
|
|||
/// Switch the compose box back to regular non-edit mode, with no content. | |||
void endEditInteraction(); | |||
|
|||
/// Uploads the provided files, populating the compose box with their links. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: s/compose box/content input/
assets/l10n/app_en.arb
Outdated
"errorSharingAccountNotLoggedIn": "There was no account logged in. Please login to an account and try again.", | ||
"@errorSharingAccountNotLoggedIn": { | ||
"description": "Error title when sharing content received from other apps fails, when there was no account logged in" | ||
}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nits:
- "There is" instead of "There was", right?
- "log in" for the verb, as here; "login" for the noun
lib/widgets/share.dart
Outdated
// Try to focus on the topic compose box if there is one, | ||
// else focus on content compose box, if not already focused. | ||
if (composeBoxController is StreamComposeBoxController) { | ||
if (!composeBoxController.topicFocusNode.hasFocus) { | ||
composeBoxController.topicFocusNode.requestFocus(); | ||
} | ||
} else { | ||
if (!composeBoxController.contentFocusNode.hasFocus) { | ||
composeBoxController.contentFocusNode.requestFocus(); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about composeBoxController.requestFocusIfUnfocused()
for this? 🙂
}); | ||
|
||
final bool disableChannelActionSheet; | ||
final bool hideChannelsIfUserCantPost; | ||
final OnChannelSelectCallback? onChannelSelect; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about adding TODO(#412) add onTopicSelect
? In share-to-Zulip, that will probably be more useful to people, when we get around to it :) especially if it includes a "new topic" button as I've just suggested on that issue: #412 (comment)
…Actually, trying this out now, I think we might (eventually) want to force the user to choose a topic or start a new topic. When I land on the interleaved channel view with an empty topic input, the first thing I tried was to tap a topic recipient header, which brought me to a new page (the topic page) without the content I wanted to share.
import android.net.Uri | ||
import android.provider.OpenableColumns | ||
|
||
class IntentListener : AndroidIntentEventsStreamHandler() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: How about:
-
naming this
IntentEventListener
or evenAndroidIntentEventListener
, corresponding toAndroidIntentsEventChannelApi.androidIntentEvents()
-
and below, rename
onAndroidIntentEvent
to justonEvent
?
This way feels more streamlined in loading the "event" abstraction into the reader's brain, and also making it clear that "android intent event" is the only kind of event this class is responsible for.
// App can receive both an EXTRA_TEXT and EXTRA_STREAM (files) | ||
// in the same intent. And the documentation states that EXTRA_TEXT | ||
// should always be "text/plain", and it also states that it can be | ||
// a mime type of the file/s in EXTRA_STREAM, but while testing | ||
// Chrome seems to always set this as the source URL for the shared | ||
// image, and Firefox sets this to null when sharing an image. | ||
// So, we use this string as-is, assuming the documented later part | ||
// isn't observed in the wild. | ||
// | ||
// See: https://developer.android.com/reference/android/content/Intent#ACTION_SEND | ||
val extraText = intent.getStringExtra(Intent.EXTRA_TEXT) | ||
|
||
val event = when (intent.action) { | ||
Intent.ACTION_SEND -> { | ||
if ("text/plain" == intent.type) { | ||
AndroidIntentSendEvent( | ||
action = Intent.ACTION_SEND, | ||
extraText = extraText | ||
) | ||
} else { | ||
// TODO(android-sdk-33) Remove the use of deprecated API. | ||
@Suppress("DEPRECATION") val url = intent.getParcelableExtra<Uri>(Intent.EXTRA_STREAM) | ||
?: throw Exception("Could not extract URL from File Intent") | ||
val sharedFile = getIntentSharedFile(context, url) | ||
AndroidIntentSendEvent( | ||
action = Intent.ACTION_SEND, | ||
extraText = extraText, | ||
extraStream = listOf(sharedFile) | ||
) | ||
} | ||
} | ||
|
||
Intent.ACTION_SEND_MULTIPLE -> { | ||
// TODO(android-sdk-33) Remove the use of deprecated API. | ||
@Suppress("DEPRECATION") val urls = | ||
intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM) | ||
?: throw Exception("Could not extract URLs from File Intent") | ||
val extraStream = mutableListOf<IntentSharedFile>() | ||
for (url in urls) { | ||
val sharedFile = getIntentSharedFile(context, url) | ||
extraStream.add(sharedFile) | ||
} | ||
AndroidIntentSendEvent( | ||
action = Intent.ACTION_SEND_MULTIPLE, | ||
extraText = extraText, | ||
extraStream = extraStream | ||
) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this simplifies to the following:
- Comment that the doc is wrong to imply that
EXTRA_TEXT
andEXTRA_STREAM
can't both be present ("get*Extra can have either a EXTRA_TEXT or EXTRA_STREAM field"); this happens empirically - First, check for
EXTRA_STREAM
. If it's present, include the file(s). - Add
EXTRA_TEXT
, too, if that's present. No mime-type check (the current revision defeats sharing a plain-text file). - Maybe a comment that
EXTRA_TEXT
might be text that the user doesn't want, like the URL of an image on the web. But, shrug, we're including it anyway because that's what Chrome/etc. put in the intent, and it's the other app's responsibility to choose what it intends to be shared.
Is that right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Correct. Also, sent a new revision further simplifying and eliminating the if ("text/plain" == intent.type) {
branch, PTAL.
lib/widgets/share.dart
Outdated
sharedFiles: intentSendEvent.extraStream?.map((sharedFile) { | ||
return FileToUpload( | ||
content: Stream.value(sharedFile.bytes), | ||
length: sharedFile.bytes.length, | ||
filename: sharedFile.name, | ||
mimeType: sharedFile.mimeType); | ||
}), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we want to call lookupMimeType
when sharedFile.mimeType
is null? (See example in compose_box.dart)
If that's complicated, we could file a followup for it.
lib/widgets/share.dart
Outdated
SubscriptionListPageBody( | ||
disableChannelActionSheet: true, | ||
hideChannelsIfUserCantPost: true, | ||
onChannelSelect: _handleNarrowSelect), | ||
RecentDmConversationsPageBody( | ||
hideDmsIfUserCantPost: true, | ||
onDmSelect: _handleNarrowSelect), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's nice to do a tearoff for _handleNarrowSelect
, i.e. onChannelSelect: _handleNarrowSelect
vs. onChannelSelect: (…) => _handleNarrowSelect(…)
. But is that the only reason that _handleNarrowSelect
is getting passed a BuildContext
from much deeper than this in the widget tree?
If _handleNarrowSelect
adds some async work up front (which, true, it doesn't have right now), then we'll need an isMounted
check after the await
…and then I think we need to investigate if something like a channel-rename can cause the context to unmount, if the context is on a per-channel widget.
Simpler to just use this widget's context, if we can:
onChannelSelect: (narrow) => _handleNarrowSelect(narrow, context)
right?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm, I had added the BuildContext because in the initial draft I was observing a bug that pressing back from the message list page, when opened from new dm conversation action sheet. I would be led to the share page again instead of the home page. Even with the extra Navigator.pop
for the action sheet. And I had thought that adding the BuildContext and passing that for Navigator was what fixed it.
But for some reason after I've removed this BuildContext now, I do not observe that bug again, so looks like it was something else in the initial draft that was not working.
Anyway removed the BuildContext
in the latest revision.
lib/widgets/share.dart
Outdated
Tab(text: zulipLocalizations.channelsPageTitle), | ||
Tab(text: zulipLocalizations.recentDmConversationsPageTitle), | ||
])), | ||
body: SafeArea( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This calls for a
// Don't pad the bottom here; we want the list content to do that.
bottom: false,
. And if we're settled on repurposing SubscriptionListPageBody
and RecentDmConversationsPageBody
1, then a few more changes are needed:
- Revert the changes to those widgets in 742320c
- Move the "New DM" button up by the height of the bottom inset (the new-DM button landed after we added the bottom nav)
- Find a factoring or comment to explain why the
FooPageBody
widgets aren't uniform in these details
Footnotes
-
I wonder if this is a case of "prefer duplication over the wrong abstraction": https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction ↩
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think the "hide channels/conversations where the user can't post" flags, and the "don't offer channel action sheet" flag, are definitely signs that these existing "page body" widgets are probably not the abstractions we'd ideally want to be sharing, and that the ideal way to factor this would probably involve two new widgets and a certain amount of duplication.
OTOH there's quite a bit of logic in both of these pages, and the bulk of it I wouldn't want to duplicate: we really do mean for the the bulk of that behavior and UI to be uniform between this sharing context and the home-tabs context, and would risk divergence if we started having two copies of it. I think getting to the point where we could duplicate the right bits without duplicating all the rest would require some nontrivial refactoring.
So I think repurposing those widgets is the right solution for the present.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the SafeArea: the page-body widgets are already taking care of that, right? As Chris says, those need bottom: false
added. Then they'll continue to take care of the left and right insets; and the top inset is handled by the app bar.
So I think this SafeArea can be omitted. Is there something that goes wrong if it's left out?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For the SafeArea: the page-body widgets are already taking care of that, right?
Taking care of what specifically? 🙂
In this revision, in the emulator I'm running, the middle of the list looks like this:

No change if I remove this SafeArea
.
It should look like this, in the middle of the list:

And this, at the bottom of the list:

But it doesn't because 742320c stopped delegating to list content the job of detecting and padding the bottom inset, and instead relied on the assumption that that's handled externally, by the bottom tabs.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right. So the page-body widgets are already taking care of the left and right insets (and, wrongly for this purpose, a bottom inset). There's nothing for a SafeArea here to do.
And those other SafeArea calls need to be adjusted to not try to handle a bottom inset.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sent this as c9391df, PTAL.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Responded there—for some reason, as Greg pointed out, my comment didn't end up showing on the PR thread: c9391df#r163401974
93f4417
to
7bbee53
Compare
Thanks for the review @chrisbobbe. Pushed an update, PTAL. |
This reverts part of 742320c for RecentDmConversationsPageBody and SubscriptionListPageBody, as these widgets will soon be used outside the context of home page, specifically for the upcoming share page. The other *PageBody widgets, currently only InboxPageBody, don't need these as they aren't used outside the context of the home page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Small comments below, and this continues to work in my manual testing. I guess we'll want a TODO and an issue to write tests, if we're aiming to ship this before tests are written? (Like #1620 etc.)
// App can receive both an EXTRA_TEXT and EXTRA_STREAM (files) | ||
// in the same intent. And the documentation states that EXTRA_TEXT | ||
// should always be "text/plain", and it also states that it can be | ||
// a mime type of the file/s in EXTRA_STREAM, but while testing | ||
// Chrome seems to always set this as the source URL for the shared | ||
// image, and Firefox sets this to null when sharing an image. | ||
// So, we use this string as-is, assuming the documented later part | ||
// isn't observed in the wild. | ||
// | ||
// See: https://developer.android.com/reference/android/content/Intent#ACTION_SEND | ||
val extraText = intent.getStringExtra(Intent.EXTRA_TEXT) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Following up from #1774 (comment) , this comment is still confusing to me:
- It makes it sound like the doc implies that the EXTRA_TEXT string itself represents a mime type, but the doc doesn't imply that. (It mentions
getType
giving a mime type.) - About the possibility that EXTRA_TEXT and EXTRA_STREAM are both present:
- It should make clear why we believe this behavior is possible: because we've seen it. (IOW it's not from reading code or seeing a StackOverflow thread or something)
- It should make clear why that's interesting/important: because it contradicts the Android doc, and we've created our own interpretation of what it means.
- It would be helpful to make clear what interpretation we're relying on: that files and text are both included in the share-to intent, not just one or the other.
How about:
assert(
intentAction == Intent.ACTION_SEND
|| intentAction == Intent.ACTION_SEND_MULTIPLE
)
// EXTRA_TEXT and EXTRA_STREAM are the text and file components of the content, respectively.
// The ACTION_SEND{,_MULTIPLE} docs say "either" / "or" will be present:
// https://developer.android.com/reference/android/content/Intent#ACTION_SEND
// But empirically both can be present, commonly, so we accept that form, interpreting it as
// an intent to share both kinds of data.
//
// Empirically, sometimes EXTRA_TEXT isn't something we think needs to be shared, like the URL
// of a file that's present in EXTRA_STREAM…but we shrug and include it anyway because we don't
// want to second-guess other apps' decisions about what to include; it's their responsibility.
val extraText = intent.getStringExtra(Intent.EXTRA_TEXT)
val extraStream = when (intentAction) {
Intent.ACTION_SEND -> {
// etc.
final bool disableChannelActionSheet; | ||
final bool hideChannelsIfUserCantPost; | ||
final OnChannelSelectCallback? onChannelSelect; | ||
// TODO(#412) add onTopicSelect |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this comment, could we mention the idea of forcing the user to choose a topic or start a new topic? (Or maybe somewhere more specific to the share-to code.) As I mentioned in https://github.com/zulip/zulip-flutter/pull/1774/files#r2248956083 , the current behavior can be pretty frustrating:
When I land on the interleaved channel view with an empty topic input, the first thing I tried was to tap a topic recipient header, which brought me to a new page (the topic page) without the content I wanted to share.
lib/widgets/compose_box.dart
Outdated
/// If any of the file is larger than maximum file size allowed by the | ||
/// server, an error dialog is shown mentioning their names and actual | ||
/// file sizes. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/// If any of the file is larger than maximum file size allowed by the | |
/// server, an error dialog is shown mentioning their names and actual | |
/// file sizes. | |
/// If any of the files are larger than maximum file size allowed by the | |
/// server, an error dialog is shown mentioning their names and actual | |
/// file sizes. |
} | ||
|
||
if (extraText == null && extraStream == null) { | ||
throw Exception("Got unexpected ACTION_SEND* intent, without both EXTRA_TEXT and EXTRA_STREAM") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
throw Exception("Got unexpected ACTION_SEND* intent, without both EXTRA_TEXT and EXTRA_STREAM") | |
throw Exception("Got unexpected ACTION_SEND* intent, with neither EXTRA_TEXT nor EXTRA_STREAM") |
(It's OK if the intent doesn't have both things, but not OK if it has zero of them.)
45b9b60
to
22afc6b
Compare
The reason for disabling the channel action sheet, was that user could open the topic list from there and if tapped on a topic, the compose box in the topic narrow message list wouldn't be populated with the shared content. See discussion: #mobile-design > share to Zulip @ 💬. The latest revision now hides the topic list button from the channel action sheet (when on share page), instead of disabling the whole channel action sheet. |
Thanks for the reviews @chrisbobbe, @gnprice! Pushed an update, PTAL. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @rajveermalviya for building this, and @chrisbobbe for the previous reviews!
Here's a full review of all but the last/main commit (so the first 10 commits). Reading that last/main commit now.
lib/widgets/message_list.dart
Outdated
GlobalKey<MessageListPageState>? key, | ||
int? accountId, | ||
BuildContext? context, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: key
is for the widget, so put it next to the other widget arguments
GlobalKey<MessageListPageState>? key, | |
int? accountId, | |
BuildContext? context, | |
int? accountId, | |
BuildContext? context, | |
GlobalKey<MessageListPageState>? key, |
lib/widgets/subscription_list.dart
Outdated
for (final subscription in store.subscriptions.values) { | ||
if (widget.hideChannelsIfUserCantPost) { | ||
if (!store.hasPostingPermission(inChannel: subscription, | ||
user: store.selfUser, byDate: DateTime.now())) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cleanest to use a single timestamp (a single byDate
value) through the whole list.
lib/widgets/subscription_list.dart
Outdated
if (!store.hasPostingPermission(inChannel: subscription, | ||
user: store.selfUser, byDate: DateTime.now())) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: indent arguments
if (!store.hasPostingPermission(inChannel: subscription, | |
user: store.selfUser, byDate: DateTime.now())) { | |
if (!store.hasPostingPermission(inChannel: subscription, | |
user: store.selfUser, byDate: DateTime.now())) { |
if (widget.hideDmsIfUserCantPost) { | ||
final hasDeactivatedUser = | ||
narrow.otherRecipientIds.any( | ||
(id) => !(store.getUser(id)?.isActive ?? true)); | ||
if (hasDeactivatedUser) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This logic doesn't implement all the nuances that the name suggests it would.
That's fine for now, but it should get a TODO comment — pointing to #791.
|
||
void _handleDmSelectForNewDms(DmNarrow narrow) { | ||
if (widget.onDmSelect case final onDmSelect?) { | ||
// Pop the new DMs action sheet. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
// Pop the new DMs action sheet. | |
// Pop the new-DMs action sheet. |
Otherwise it reads like it's a "DMs action sheet" which is new.
@@ -65,6 +87,12 @@ class _SubscriptionListPageBodyState extends State<SubscriptionListPageBody> wit | |||
}); | |||
} | |||
|
|||
void _handleChannelSelect(ChannelNarrow narrow) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: this name risks confusion, because this method is only sometimes the way that this widget handles selecting a channel.
Good alternatives would be:
- name it with "default", as in
_defaultHandleChannelSelect
- or move the fallback logic inside this method (like
_RecentDmConversationsPageBodyState
does) so that the method's role matches its current name
/// Callback to invoke when the user selects a channel from the list. | ||
/// | ||
/// If null, the default behavior is to navigate to the channel feed. | ||
final OnChannelSelectCallback? onChannelSelect; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
subscription_list [nfc]: Allow `onChannelSelect` callback, notifying the selected channel
To "notify the channel" would mean telling the channel something — the channel would be the listener, the thing that's learning information. But that's not what this does.
I think that whole clause ("notifying the selected channel") can be deleted; once "notifying" is replaced with an accurate verb, it doesn't really convey any information that's not there in the name onChannelSelect
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Similarly for a later commit.)
lib/widgets/compose_box.dart
Outdated
/// If there is an error while uploading a file, then an error dialog is | ||
/// shown mentioning the corresponding file name. | ||
Future<void> uploadFiles({ | ||
required Iterable<FileToUpload> files, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: put these two commits next to each other in the branch:
8a3bd63 compose [nfc]: Export and rename _File to FileToUpload
fb4d254 compose [nfc]: Expose uploadFiles
on ComposeBoxState
because they're closely related — the latter is basically the motivation for the former. (At present they're separated by a half-dozen other commits which these are less related to than they are to each other.)
lib/widgets/compose_box.dart
Outdated
await _uploadFiles( | ||
context: context, | ||
contentController: controller.content, | ||
contentFocusNode: controller.contentFocusNode, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like this method really belongs on ComposeBoxController, rather than ComposeBoxState. The state it's manipulating is all on the controller, not on this overall state object.
(And the caller which has a ComposeBoxState can already easily get a ComposeBoxController, through the controller
getter there.)
One reason that would be good is that it would mean the other call site of _uploadFiles
(in _AttachUploadsButton._handlePress
) could call the very same method. It makes me a bit nervous to have this uploadFiles
method on ComposeBoxState be doing not quite the same thing as we do when the user uploads files through one of the compose buttons.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll send a separate PR for updating _AttachUploadsButton._handlePress
(stacked on top of this commit).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sent #1801 for this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
OK, and here's a review from reading the remaining, main commit.
Generally this all looks good — mostly nits, and just a couple of more substantive comments here and above.
pigeon/android_intents.dart
Outdated
|
||
class AndroidIntentSendEvent extends AndroidIntentEvent { | ||
const AndroidIntentSendEvent({ | ||
required this.action, // 'android.intent.action.SEND' or 'android.intent.action.SEND_MULTIPLE' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: for a comment explaining a field, put it on the field declaration, not the constructor parameter
lib/widgets/share.dart
Outdated
|
||
assert(debugLog('intentSendEvent.action: ${intentSendEvent.action}')); | ||
assert(debugLog('intentSendEvent.extraText: ${intentSendEvent.extraText}')); | ||
assert(debugLog('intentSendEvent.extraStream: [${intentSendEvent.extraStream?.join(',')}]')); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These objects are potentially carrying a lot of bytes, right? Seems like more than we'd want to log, even in debug.
Oh but it looks like there's no toString override on these objects. So it won't print all the contents… but it won't really print anything useful, either, about the details of any of these.
Seems like the interesting information this prints, then, is purely the number of these items (and the fact of whether the list is present or null). Let's make that explicit, then: print the list's length, instead of .join(…)
.
lib/widgets/share.dart
Outdated
assert(debugLog('intentSendEvent.extraText: ${intentSendEvent.extraText}')); | ||
assert(debugLog('intentSendEvent.extraStream: [${intentSendEvent.extraStream?.join(',')}]')); | ||
|
||
NavigatorState navigator = await ZulipApp.navigator; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NavigatorState navigator = await ZulipApp.navigator; | |
final navigator = await ZulipApp.navigator; |
It doesn't get mutated, right?
lib/widgets/share.dart
Outdated
|
||
// TODO(#524) choose initial account as last one used | ||
// TODO(#1779) allow selecting account, if there are multiple | ||
final initialAccountId = globalStore.accounts.firstOrNull?.id; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why "initial"? Seems like this is simply the account ID that will get used here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bump — accountId
seems like the better name for this
("initial" would fit if we were starting with this account and then might switch to another one later)
lib/widgets/share.dart
Outdated
mimeType ??= lookupMimeType( | ||
// Seems like the path shouldn't be required; we still want to look for | ||
// matches on `headerBytes`. Thankfully we can still do that, by calling | ||
// lookupMimeType with the empty string as the path. That's a value that | ||
// doesn't map to any particular type, so the path will be effectively | ||
// ignored, as desired. Upstream comment: | ||
// https://github.com/dart-lang/mime/issues/11#issuecomment-2246824452 | ||
'', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We do have a path, though: sharedFile.name
. (The only part of the path that lookupMimeType
is going to look at anyway is the extension, so it's fine that that's a basename and not a full path.)
Oh but that name might be something we made up as a fallback, hmm:
} ?: ("unknown." + (mimeType?.split('/')?.last() ?: "bin"))
in which case we want to let lookupMimeType
do its thing with headerBytes
instead of using the name.
Well:
- Let's move that
unknown.foo
fallback logic into this Dart code, so that we can better control its interaction with this other logic. - Then we can apply that fallback after we do this
lookupMimeType
call. - As a further bonus: that means we get to use the result of
lookupMimeType
in computing that fallback name, so we have a better shot at giving the name a more informative extension than.bin
.
Let's do those changes as a separate commit (or couple of commits) on top, though. That way it remains easy to merge this version if we're still tweaking that logic. And this version is quite coherent already, in that it corresponds more directly to what's in the legacy app.
lib/widgets/share.dart
Outdated
unawaited(navigator.push( | ||
SharePage.buildRoute( | ||
accountId: initialAccountId, | ||
sharedFiles: intentSendEvent.extraStream?.map((sharedFile) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: let's move this whole .map
out to a local variable, before the navigator.push
call — that way the navigator.push
and the description of the route stay compact
lib/widgets/share.dart
Outdated
} | ||
|
||
void _handleNarrowSelect(BuildContext context, Narrow narrow) { | ||
final messageListPageStateKey = GlobalKey<MessageListPageState>(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: name the key after the widget, not the widget's state
final messageListPageStateKey = GlobalKey<MessageListPageState>(); | |
final messageListPageKey = GlobalKey<MessageListPageState>(); |
lib/widgets/share.dart
Outdated
// Wait for the message list page to accommodate in the widget tree from the route. | ||
SchedulerBinding.instance.addPostFrameCallback((_) async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't understand what "accommodate" means in this sentence.
Does this cover what you mean?
// Wait for the message list page to accommodate in the widget tree from the route. | |
SchedulerBinding.instance.addPostFrameCallback((_) async { | |
// Wait for the message list page to appear in the widget tree. | |
SchedulerBinding.instance.addPostFrameCallback((_) async { |
lib/widgets/share.dart
Outdated
// If there are any shared files, add a separator new line. | ||
if (sharedFiles != null) text += '\n'; | ||
|
||
// Populate the content input with this text. | ||
final contentController = composeBoxController.content; | ||
contentController.value = | ||
contentController.value | ||
.replaced(contentController.insertionIndex(), text); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The use of insertionIndex
feels like it's deep into the details of how the controller works, so that this logic probably belongs in a method on ComposeContentController.
There's already an insertPadded
method on the controller, which has a lot in common with what this is trying to do. How about this?
// If there are any shared files, add a separator new line. | |
if (sharedFiles != null) text += '\n'; | |
// Populate the content input with this text. | |
final contentController = composeBoxController.content; | |
contentController.value = | |
contentController.value | |
.replaced(contentController.insertionIndex(), text); | |
composeBoxController.content.insertPadded(text); |
Is there an aspect of behavior you'd want to be different from what that would do?
assets/l10n/app_en.arb
Outdated
"@channelsPageTitle": { | ||
"description": "Title for the page with a list of subscribed channels." | ||
}, | ||
"sharePageTitle": "Share", | ||
"@sharePageTitle": { | ||
"description": "Title for the page about sharing content received from other apps." | ||
}, | ||
"channelsEmptyPlaceholder": "You are not subscribed to any channels yet.", | ||
"@channelsEmptyPlaceholder": { | ||
"description": "Centered text on the 'Channels' page saying that there is no content to show." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: this is separating two items that are more closely related to each other than either is to this one
Moving this to just after channelsEmptyPlaceholder
would be fine.
(I've also tried this PR out in some manual testing, on my Pixel 8 running Android 15, and all seems well.) |
22afc6b
to
4b26ffc
Compare
This will be used soon to access `MessageListPageState` after routing to `MessageListPage`.
… action sheet This will be used soon to avoid unintended flows when sharing content received from other apps.
…an't post This will be used soon to avoid showing conversations from the channel list where the current user can't post, specifically when they don't have the permission.
This will be used soon to provide specific behaviour when selecting a channel, where if specified it will replace the default behaviour of routing to the message list page of the selected channel narrow.
This will be used soon to avoid showing conversations from the recent DMs list where the current user can't post, specifically when a conversation has one or more deactivated user.
This will be used soon to provide a specific behaviour when selecting a DM, where if specified it will replace the default behaviour of routing to the message list page of the selected DM narrow.
4b26ffc
to
9bcc247
Compare
Thanks for the review @gnprice! Pushed an update, PTAL. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the revision! Just a few nits below (and I see you have a follow-up planned at #1774 (comment), and #1787 for writing a few tests).
/// | ||
/// If there is an error while uploading a file, then an error dialog is | ||
/// shown mentioning the corresponding file name. | ||
Future<void> uploadFiles({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit:
compose [nfc]: Expose `uploadFiles` on `ComposeBoxState`
it's on ComposeBoxController
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
While here I made a few other edits to the commit message:
- compose [nfc]: Expose `uploadFiles` on `ComposeBoxState`
+ compose [nfc]: Expose an uploadFiles method on ComposeBoxController
- This will be used soon to allow uploading files directly
- via using `MessageListPageState`.
+ This will be used soon to allow uploading files from outside
+ this source file, via using `MessageListPageState`.
lib/widgets/share.dart
Outdated
|
||
assert(debugLog('intentSendEvent.action: ${intentSendEvent.action}')); | ||
assert(debugLog('intentSendEvent.extraText: ${intentSendEvent.extraText}')); | ||
assert(debugLog('intentSendEvent.extraStream?.length: [${intentSendEvent.extraStream?.length}]')); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: the brackets seem puzzling
lib/widgets/share.dart
Outdated
|
||
// TODO(#524) choose initial account as last one used | ||
// TODO(#1779) allow selecting account, if there are multiple | ||
final initialAccountId = globalStore.accounts.firstOrNull?.id; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
bump — accountId
seems like the better name for this
("initial" would fit if we were starting with this account and then might switch to another one later)
lib/widgets/share.dart
Outdated
sharedFile.bytes.take(defaultMagicNumbersMaxLength))); | ||
|
||
final filename = | ||
sharedFile.name ?? 'unknown.${mimeType?.split('/').lastOrNull ?? 'bin'}'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: split
can't return an empty list
sharedFile.name ?? 'unknown.${mimeType?.split('/').lastOrNull ?? 'bin'}'; | |
sharedFile.name ?? 'unknown.${mimeType?.split('/').last ?? 'bin'}'; |
In fact I'll fix those nits and then merge — that way I can make a release tonight completely from main 🎉 |
This will be used soon to allow uploading files from outside this source file, via using `MessageListPageState`.
…ion list page This reverts part of 742320c for SubscriptionListPageBody, as this widget is planned to be used outside the context of home page, specifically for the upcoming share page.
This reverts part of 742320c for RecentDmConversationsPageBody, and also handles bottom insets for the new DMs button. As this widget is planned to be used outside the context of home page, specifically for the upcoming share page.
Enables the app to receive arbitrary content from other apps via advertising Android Intent filters in AndroidManifest. It allows the OS to list our app in the platform share sheet. Adds handlers for the two Intent actions, namely SEND and SEND_MULTIPLE. Handling all three possible combinations: - Receiving only a text - Receiving only a file (or multiple files in case of SEND_MULTIPLE) - Receiving both the file (or multiple) and the accompanying text. The Android side Kotlin implementation is adapted from the legacy app's implementation, with only difference being that the legacy app didn't handle the 3rd case mentioned above, see: https://github.com/zulip/zulip-mobile/blob/eb8505c4a/android/app/src/main/java/com/zulipmobile/sharing/SharingHelper.kt To allow sending Android Intent events from Kotlin to Dart, Pigeon's EventChannelApi is used. For which the registration happens in `MainActivity.configureFlutterEngine`, this bit of code was adapted from Pigeon's Android example, see: https://github.com/flutter/packages/blob/b2aef15c1/packages/pigeon/example/app/android/app/src/main/kotlin/dev/flutter/pigeon_example_app/MainActivity.kt#L109-L121
Use the received file's name when trying to guess the mimetype in addition to checking for magic header bytes, using `lookupMimeType`.
9bcc247
to
529e037
Compare
…Button In e4deccb (zulip#1774) ComposeBoxController started exposing an uploadFiles method, which is now used by the Share-to-Zulip implementation. So use that for _AttachUploadsButton too, so as to use the same implementation between Share-to-Zulip and compose box upload buttons.
Screenshots
Screen recordings
channels-single-file.mp4
channels-multiple-files.mp4
channels-text.mp4
channels-text+image.mp4
dms-single-file.mp4
new-dms-multiple-file.mp4
dms-text.mp4
new-dms-text+image.mp4
Fixes: #53